#!/usr/bin/env python3
# Copyright 2021 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Builds an app that uses Material Web Components

This script runs the tooling necessary to produce an output script that
can be run by Chromium for a project using Material Web Components.

The main steps are:
- Generate depfiles with all the transitive MWC dependencies for GN to
  correctly identify dirty builds.
- Use rollup to resolve bare imports and generate a single application package.
- Run Terser to minify the output.
"""

# TODO(calamity): This is mostly a copy of optimize_webui.py. Figure out
# which parts of this script are unnecessary and remove.

import argparse
import itertools
import json
import os
import glob
import platform
import re
import shutil
import sys
import tempfile


_HERE_PATH = os.path.dirname(__file__)
_SRC_PATH = os.path.normpath(os.path.join(_HERE_PATH, '..', '..'))
_CWD = os.getcwd()  # typically out/<gn_name>/.
_BASE_EXCLUDES = []
_URL_MAPPINGS = []
_MWC_PATH = os.path.join(_SRC_PATH, 'third_party', 'material_web_components',
                         'components-chromium', 'node_modules')

sys.path.append(os.path.join(_SRC_PATH, 'third_party', 'node'))
import node
import node_modules


_DEBUG_DLOG_ENABLED = False


def DLOG(*args):
    if _DEBUG_DLOG_ENABLED:
        print(*args)


def _request_list_path(out_path, host_url):
    host = host_url[host_url.find('://') + 3:-1]
    return os.path.join(out_path, host + '_requestlist.txt')


def _get_dep_path(dep, host_url, in_path, out_path):
    DLOG('Input dep: ' + dep)
    dep = dep.replace('../', '', 1)
    DLOG('Host URL : ' + host_url)
    DLOG('in_path  : ' + in_path)
    if dep.startswith(host_url):
        result = dep.replace(host_url, os.path.relpath(in_path, _CWD))
    elif not (dep.startswith('chrome://') or dep.startswith('//')):
        result = os.path.relpath(out_path, _CWD) + '/' + dep
    else:
        result = dep
    DLOG('Result   : ' + result, '\n')

    return result

# Get a list of all files that were bundled with rollup and update the
# depfile accordingly such that Ninja knows when to re-trigger.
def _update_dep_file(args, manifest):
    in_path = os.path.join(_CWD, args.input)
    out_path = os.path.join(_CWD, args.out_folder)

    # Gather the dependencies of all bundled root files.
    request_list = []
    for out_file in manifest:
        request_list += manifest[out_file]

    request_list = map(
        lambda dep: _get_dep_path(dep, args.host_url, in_path, out_path),
        request_list)

    deps = map(os.path.normpath, request_list)

    out_file_name = args.js_out_files[0]

    with open(os.path.join(_CWD, args.depfile), 'w') as f:
        deps_file_header = os.path.join(args.out_folder, out_file_name)
        f.write(deps_file_header + ': ' + ' '.join(deps))


# Autogenerate a rollup config file so that we can import the plugin and
# pass it information about the location of the directories and files to exclude
# from the bundle.
def _generate_rollup_config(tmp_out_dir, path_to_plugin, in_path, host_url,
                            excludes, external_paths):
    rollup_config_file = os.path.join(tmp_out_dir, 'rollup.config.js')
    excludes_string = '[' + ', '.join(["'%s'" % e for e in excludes]) + ']'
    config_content = r'''
    import plugin from '{plugin_path}';
    export default ({{
      plugins: [
        plugin('{in_path}', '{host_url}', {exclude_list},
               {external_path_list}, /* allowEmptyExtension= */ true) ]
    }});
    '''.format(plugin_path=path_to_plugin.replace('\\', '/'),
               in_path=in_path.replace('\\', '/'),
               host_url=host_url,
               exclude_list=json.dumps(excludes),
               external_path_list=json.dumps(external_paths),
               node_root_dir=_MWC_PATH)
    DLOG('Rollup Config:\n' + config_content)

    with open(rollup_config_file, 'w') as f:
        f.write(config_content)
    return rollup_config_file


# Create the manifest file from the sourcemap generated by rollup and return the
# list of bundles.
def _generate_manifest_file(tmp_out_dir, in_path, request_list_path):
    generated_sourcemaps = glob.glob('%s/*.map' % tmp_out_dir)
    manifest = {}
    output_filenames = []
    for sourcemap_file in generated_sourcemaps:
        with open(sourcemap_file, 'r') as f:
            sourcemap = json.loads(f.read())
            if not 'sources' in sourcemap:
                raise Exception('rollup could not construct source map')
            sources = sourcemap['sources']
            replaced_sources = []
            for source in sources:
                source.replace('../', '', 1)
                replaced_sources.append(
                    source.replace('../' + os.path.basename(in_path) + '/',
                                   ''))
            filename = sourcemap_file[:-len('.map')]
            manifest[os.path.basename(filename)] = replaced_sources
            output_filenames.append(filename)

    with open(request_list_path, 'w') as f:
        f.write(json.dumps(manifest))

    return output_filenames


def build(tmp_out_dir, in_path, out_path, request_list_path, args, excludes,
          external_paths):
    if not os.path.exists(tmp_out_dir):
        os.makedirs(tmp_out_dir)
    path_to_plugin = os.path.join(os.path.abspath(_SRC_PATH), 'chrome',
                                  'browser', 'resources', 'tools',
                                  'rollup_plugin.js')
    rollup_config_file = _generate_rollup_config(tmp_out_dir, path_to_plugin,
                                                 in_path, args.host_url,
                                                 excludes, external_paths)
    rollup_args = [os.path.join(in_path, f) for f in args.js_module_in_files]

    # Confirm names are as expected. This is necessary to avoid having to replace
    # import statements in the generated output files.
    # TODO(calamity): Is it worth adding import statement replacement to support
    # arbitrary names?
    bundled_paths = []
    for index, js_file in enumerate(args.js_module_in_files):
        base_file_name = os.path.basename(js_file)
        expected_name = '%s.rollup.js' % base_file_name[:-len('.js')]
        assert args.js_out_files[index] == expected_name, \
               'Output file corresponding to %s should be named %s' % \
               (js_file, expected_name)
        bundled_paths.append(os.path.join(tmp_out_dir, expected_name))

    # This indicates that rollup is expected to generate a shared chunk file as
    # well as one file per module. Set its name using --chunkFileNames. Note:
    # Currently, this only supports 2 entry points, which generate 2 corresponding
    # outputs and 1 shared output.
    if (len(args.js_out_files) == 3):
        assert len(args.js_module_in_files) == 2, \
               'Expect 2 module entry points for generating 3 outputs'
        shared_file_name = args.js_out_files[2]
        rollup_args += ['--chunkFileNames', shared_file_name]
        bundled_paths.append(os.path.join(tmp_out_dir, shared_file_name))

    node.RunNode([node_modules.PathToRollup()] + rollup_args + [
        '--format',
        'esm',
        '--dir',
        tmp_out_dir,
        '--entryFileNames',
        '[name].rollup.js',
        '--sourcemap',
        '--sourcemapExcludeSources',
        '--config',
        rollup_config_file,
        '--silent',
    ])

    # Create the manifest file from the sourcemaps generated by rollup.
    generated_paths = _generate_manifest_file(tmp_out_dir, in_path,
                                              request_list_path)
    assert len(generated_paths) == len(bundled_paths), \
           'unexpected number of bundles - %s - generated by rollup' % \
           (len(generated_paths))

    for bundled_file in bundled_paths:
        with open(bundled_file, 'r') as f:
            output = f.read()
            assert "<if expr" not in output, \
                'Unexpected <if expr> found in bundled output. Check that all ' + \
                'input files using such expressions are preprocessed.'

    return bundled_paths


def _build(in_folder, args):
    in_path = os.path.normpath(os.path.join(_CWD,
                                            in_folder)).replace('\\', '/')
    out_path = os.path.join(_CWD, args.out_folder).replace('\\', '/')
    request_list_path = _request_list_path(out_path, args.host_url)
    tmp_out_dir = tempfile.mkdtemp(dir=out_path).replace('\\', '/')

    excludes = _BASE_EXCLUDES + [
        # This file is dynamically created by C++. Need to specify an exclusion
        # URL for both the relative URL and chrome:// URL syntax.
        'strings.js',
        'strings.m.js',
        '%s/strings.js' % args.host_url,
        '%s/strings.m.js' % args.host_url,
    ]
    excludes.extend(args.exclude or [])
    external_paths = args.external_paths or []

    try:
        if args.js_module_in_files:
            bundled_paths = build(tmp_out_dir, in_path, out_path,
                                  request_list_path, args, excludes,
                                  external_paths)

        # Pass the JS files through Uglify and write the output to its final
        # destination.
        for index, js_out_file in enumerate(args.js_out_files):
            node.RunNode([
                node_modules.PathToTerser(),
                os.path.join(tmp_out_dir, js_out_file), '--comments',
                '/Copyright|license|LICENSE|\<\/?if/', '--output',
                os.path.join(out_path, js_out_file)
            ])
    finally:
        shutil.rmtree(tmp_out_dir)
    return request_list_path


def main(argv):
    parser = argparse.ArgumentParser()
    parser.add_argument('--depfile', required=True, help='GN depfile to write')
    parser.add_argument('--exclude',
                        nargs='*',
                        help='paths that rollup will not rewrite')
    parser.add_argument('--external_paths',
                        nargs='*',
                        help='url to filesystem path replacements')
    parser.add_argument('--host', required=True, help='host of the WebUI')
    parser.add_argument('--input',
                        required=True,
                        help='directory where input files are')
    parser.add_argument('--js_out_files', nargs='*', required=True)
    parser.add_argument('--out_folder', required=True)
    parser.add_argument('--js_module_in_files', nargs='*', required=True)
    parser.add_argument('--out_manifest',
                        help='manifest file to auto-generate grd')
    args = parser.parse_args(argv)

    args.depfile = os.path.normpath(args.depfile)
    args.input = os.path.normpath(args.input)
    args.out_folder = os.path.normpath(args.out_folder)
    scheme_end_index = args.host.find('://')
    if (scheme_end_index == -1):
        args.host_url = 'chrome://%s/' % args.host
    else:
        args.host_url = args.host

    request_list_path = _build(args.input, args)

    # Prior call to _build() generated an output request_list file, containing
    # information about all files that were bundled. Grab it from there.
    request_list = json.loads(open(request_list_path, 'r').read())

    # Output a manifest file that will be used to auto-generate a grd file later.
    if args.out_manifest:
        manifest_data = {
            'base_dir': args.out_folder,
            'files': list(request_list.keys()),
        }
        with open(os.path.normpath(os.path.join(_CWD, args.out_manifest)), 'w') \
                as manifest_file:
            json.dump(manifest_data, manifest_file)

    _update_dep_file(args, request_list)


if __name__ == '__main__':
    main(sys.argv[1:])
